(function(factory) {
    /* global define */
    /* istanbul ignore next */
    if (typeof define === 'function' && define.amd) {
        define(['jquery'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node/CommonJS
        module.exports = function(root, jQuery) {
            if (jQuery === undefined) {
                if (typeof window !== 'undefined') {
                    jQuery = require('jquery');
                } else {
                    jQuery = require('jquery')(root);
                }
            }
            factory(jQuery);
            return jQuery;
        };
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function($) {
    'use strict';

    var $doc = $(document);
    var $win = $(window);

    var pluginName = 'selectric';
    var classList = 'Input Items Open Disabled TempShow HideSelect Wrapper Focus Hover Responsive Above Below Scroll Group GroupLabel';
    var eventNamespaceSuffix = '.sl';

    var chars = ['a', 'e', 'i', 'o', 'u', 'n', 'c', 'y'];
    var diacritics = [
        /[\xE0-\xE5]/g, // a
        /[\xE8-\xEB]/g, // e
        /[\xEC-\xEF]/g, // i
        /[\xF2-\xF6]/g, // o
        /[\xF9-\xFC]/g, // u
        /[\xF1]/g, // n
        /[\xE7]/g, // c
        /[\xFD-\xFF]/g // y
    ];

    /**
     * Create an instance of Selectric
     *
     * @constructor
     * @param {Node} element - The &lt;select&gt; element
     * @param {object}  opts - Options
     */
    var Selectric = function(element, opts) {
        var _this = this;

        _this.element = element;
        _this.$element = $(element);

        _this.state = {
            multiple: !!_this.$element.attr('multiple'),
            enabled: false,
            opened: false,
            currValue: -1,
            selectedIdx: -1,
            highlightedIdx: -1
        };

        _this.eventTriggers = {
            open: _this.open,
            close: _this.close,
            destroy: _this.destroy,
            refresh: _this.refresh,
            init: _this.init
        };

        _this.init(opts);
    };

    Selectric.prototype = {
        utils: {
            /**
             * Detect mobile browser
             *
             * @return {boolean}
             */
            isMobile: function() {
                return /android|ip(hone|od|ad)/i.test(navigator.userAgent);
            },

            /**
             * Escape especial characters in string (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
             *
             * @param  {string} str - The string to be escaped
             * @return {string}       The string with the special characters escaped
             */
            escapeRegExp: function(str) {
                return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
            },

            /**
             * Replace diacritics
             *
             * @param  {string} str - The string to replace the diacritics
             * @return {string}       The string with diacritics replaced with ascii characters
             */
            replaceDiacritics: function(str) {
                var k = diacritics.length;

                while (k--) {
                    str = str.toLowerCase().replace(diacritics[k], chars[k]);
                }

                return str;
            },

            /**
             * Format string
             * https://gist.github.com/atesgoral/984375
             *
             * @param  {string} f - String to be formated
             * @return {string}     String formated
             */
            format: function(f) {
                var a = arguments; // store outer arguments
                return ('' + f) // force format specifier to String
                    .replace( // replace tokens in format specifier
                        /\{(?:(\d+)|(\w+))\}/g, // match {token} references
                        function(
                            s, // the matched string (ignored)
                            i, // an argument index
                            p // a property name
                        ) {
                            return p && a[1] // if property name and first argument exist
                                ?
                                a[1][p] // return property from first argument
                                :
                                a[i]; // assume argument index and return i-th argument
                        });
            },

            /**
             * Get the next enabled item in the options list.
             *
             * @param  {object} selectItems - The options object.
             * @param  {number}    selected - Index of the currently selected option.
             * @return {object}               The next enabled item.
             */
            nextEnabledItem: function(selectItems, selected) {
                while (selectItems[selected = (selected + 1) % selectItems.length].disabled) {
                    // empty
                }
                return selected;
            },

            /**
             * Get the previous enabled item in the options list.
             *
             * @param  {object} selectItems - The options object.
             * @param  {number}    selected - Index of the currently selected option.
             * @return {object}               The previous enabled item.
             */
            previousEnabledItem: function(selectItems, selected) {
                while (selectItems[selected = (selected > 0 ? selected : selectItems.length) - 1].disabled) {
                    // empty
                }
                return selected;
            },

            /**
             * Transform camelCase string to dash-case.
             *
             * @param  {string} str - The camelCased string.
             * @return {string}       The string transformed to dash-case.
             */
            toDash: function(str) {
                return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
            },

            /**
             * Calls the events registered with function name.
             *
             * @param {string}    fn - The name of the function.
             * @param {number} scope - Scope that should be set on the function.
             */
            triggerCallback: function(fn, scope) {
                var elm = scope.element;
                var func = scope.options['on' + fn];
                var args = [elm].concat([].slice.call(arguments).slice(1));

                if ($.isFunction(func)) {
                    func.apply(elm, args);
                }

                $(elm).trigger(pluginName + '-' + this.toDash(fn), args);
            },

            /**
             * Transform array list to concatenated string and remove empty values
             * @param  {array} arr - Class list
             * @return {string}      Concatenated string
             */
            arrayToClassname: function(arr) {
                var newArr = $.grep(arr, function(item) {
                    return !!item;
                });

                return $.trim(newArr.join(' '));
            }
        },

        /** Initializes */
        init: function(opts) {
            var _this = this;

            // Set options
            _this.options = $.extend(true, {}, $.fn[pluginName].defaults, _this.options, opts);

            _this.utils.triggerCallback('BeforeInit', _this);

            // Preserve data
            _this.destroy(true);

            // Disable on mobile browsers
            if (_this.options.disableOnMobile && _this.utils.isMobile()) {
                _this.disableOnMobile = true;
                return;
            }

            // Get classes
            _this.classes = _this.getClassNames();

            // Create elements
            var input = $('<input/>', { 'class': _this.classes.input, 'readonly': _this.utils.isMobile() });
            var items = $('<div/>', { 'class': _this.classes.items, 'tabindex': -1 });
            var itemsScroll = $('<div/>', { 'class': _this.classes.scroll });
            var wrapper = $('<div/>', { 'class': _this.classes.prefix, 'html': _this.options.arrowButtonMarkup });
            var label = $('<span/>', { 'class': 'label' });
            var outerWrapper = _this.$element.wrap('<div/>').parent().append(wrapper.prepend(label), items, input);
            var hideSelectWrapper = $('<div/>', { 'class': _this.classes.hideselect });

            _this.elements = {
                input: input,
                items: items,
                itemsScroll: itemsScroll,
                wrapper: wrapper,
                label: label,
                outerWrapper: outerWrapper
            };

            if (_this.options.nativeOnMobile && _this.utils.isMobile()) {
                _this.elements.input = undefined;
                hideSelectWrapper.addClass(_this.classes.prefix + '-is-native');

                _this.$element.on('change', function() {
                    _this.refresh();
                });
            }

            _this.$element
                .on(_this.eventTriggers)
                .wrap(hideSelectWrapper);

            _this.originalTabindex = _this.$element.prop('tabindex');
            _this.$element.prop('tabindex', -1);

            _this.populate();
            _this.activate();

            _this.utils.triggerCallback('Init', _this);
        },

        /** Activates the plugin */
        activate: function() {
            var _this = this;
            var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow);
            var originalWidth = _this.$element.width();

            hiddenChildren.removeClass(_this.classes.tempshow);

            _this.utils.triggerCallback('BeforeActivate', _this);

            _this.elements.outerWrapper.prop('class',
                _this.utils.arrayToClassname([
                    _this.classes.wrapper,
                    _this.$element.prop('class').replace(/\S+/g, _this.classes.prefix + '-$&'),
                    _this.options.responsive ? _this.classes.responsive : ''
                ])
            );

            if (_this.options.inheritOriginalWidth && originalWidth > 0) {
                _this.elements.outerWrapper.width(originalWidth);
            }

            _this.unbindEvents();

            if (!_this.$element.prop('disabled')) {
                _this.state.enabled = true;

                // Not disabled, so... Removing disabled class
                _this.elements.outerWrapper.removeClass(_this.classes.disabled);

                // Remove styles from items box
                // Fix incorrect height when refreshed is triggered with fewer options
                _this.$li = _this.elements.items.removeAttr('style').find('li');

                _this.bindEvents();
            } else {
                _this.elements.outerWrapper.addClass(_this.classes.disabled);

                if (_this.elements.input) {
                    _this.elements.input.prop('disabled', true);
                }
            }

            _this.utils.triggerCallback('Activate', _this);
        },

        /**
         * Generate classNames for elements
         *
         * @return {object} Classes object
         */
        getClassNames: function() {
            var _this = this;
            var customClass = _this.options.customClass;
            var classesObj = {};

            $.each(classList.split(' '), function(i, currClass) {
                var c = customClass.prefix + currClass;
                classesObj[currClass.toLowerCase()] = customClass.camelCase ? c : _this.utils.toDash(c);
            });

            classesObj.prefix = customClass.prefix;

            return classesObj;
        },

        /** Set the label text */
        setLabel: function() {
            var _this = this;
            var labelBuilder = _this.options.labelBuilder;

            if (_this.state.multiple) {
                // Make sure currentValues is an array
                var currentValues = $.isArray(_this.state.currValue) ? _this.state.currValue : [_this.state.currValue];
                // I'm not happy with this, but currentValues can be an empty
                // array and we need to fallback to the default option.
                currentValues = currentValues.length === 0 ? [0] : currentValues;

                var labelMarkup = $.map(currentValues, function(value) {
                    return $.grep(_this.lookupItems, function(item) {
                        return item.index === value;
                    })[0]; // we don't want nested arrays here
                });

                labelMarkup = $.grep(labelMarkup, function(item) {
                    // Hide default (please choose) if more then one element were selected.
                    // If no option value were given value is set to option text by default
                    if (labelMarkup.length > 1 || labelMarkup.length === 0) {
                        return $.trim(item.value) !== '';
                    }
                    return item;
                });

                labelMarkup = $.map(labelMarkup, function(item) {
                    return $.isFunction(labelBuilder) ?
                        labelBuilder(item) :
                        _this.utils.format(labelBuilder, item);
                });

                // Limit the amount of selected values shown in label
                if (_this.options.multiple.maxLabelEntries) {
                    if (labelMarkup.length >= _this.options.multiple.maxLabelEntries + 1) {
                        labelMarkup = labelMarkup.slice(0, _this.options.multiple.maxLabelEntries);
                        labelMarkup.push(
                            $.isFunction(labelBuilder) ?
                            labelBuilder({ text: '...' }) :
                            _this.utils.format(labelBuilder, { text: '...' }));
                    } else {
                        labelMarkup.slice(labelMarkup.length - 1);
                    }
                }
                _this.elements.label.html(labelMarkup.join(_this.options.multiple.separator));

            } else {
                var currItem = _this.lookupItems[_this.state.currValue];

                _this.elements.label.html(
                    $.isFunction(labelBuilder) ?
                    labelBuilder(currItem) :
                    _this.utils.format(labelBuilder, currItem)
                );
            }
        },

        /** Get and save the available options */
        populate: function() {
            var _this = this;
            var $options = _this.$element.children();
            var $justOptions = _this.$element.find('option');
            var $selected = $justOptions.filter(':selected');
            var selectedIndex = $justOptions.index($selected);
            var currIndex = 0;
            var emptyValue = (_this.state.multiple ? [] : 0);

            if ($selected.length > 1 && _this.state.multiple) {
                selectedIndex = [];
                $selected.each(function() {
                    selectedIndex.push($(this).index());
                });
            }

            _this.state.currValue = (~selectedIndex ? selectedIndex : emptyValue);
            _this.state.selectedIdx = _this.state.currValue;
            _this.state.highlightedIdx = _this.state.currValue;
            _this.items = [];
            _this.lookupItems = [];

            if ($options.length) {
                // Build options markup
                $options.each(function(i) {
                    var $elm = $(this);

                    if ($elm.is('optgroup')) {

                        var optionsGroup = {
                            element: $elm,
                            label: $elm.prop('label'),
                            groupDisabled: $elm.prop('disabled'),
                            items: []
                        };

                        $elm.children().each(function(i) {
                            var $elm = $(this);

                            optionsGroup.items[i] = _this.getItemData(currIndex, $elm, optionsGroup.groupDisabled || $elm.prop('disabled'));

                            _this.lookupItems[currIndex] = optionsGroup.items[i];

                            currIndex++;
                        });

                        _this.items[i] = optionsGroup;

                    } else {

                        _this.items[i] = _this.getItemData(currIndex, $elm, $elm.prop('disabled'));

                        _this.lookupItems[currIndex] = _this.items[i];

                        currIndex++;

                    }
                });

                _this.setLabel();
                _this.elements.items.append(_this.elements.itemsScroll.html(_this.getItemsMarkup(_this.items)));
            }
        },

        /**
         * Generate items object data
         * @param  {integer} index      - Current item index
         * @param  {node}    $elm       - Current element node
         * @param  {boolean} isDisabled - Current element disabled state
         * @return {object}               Item object
         */
        getItemData: function(index, $elm, isDisabled) {
            var _this = this;

            return {
                index: index,
                element: $elm,
                value: $elm.val(),
                className: $elm.prop('class'),
                text: $elm.html(),
                slug: $.trim(_this.utils.replaceDiacritics($elm.html())),
                alt: $elm.attr('data-alt'),
                selected: $elm.prop('selected'),
                disabled: isDisabled
            };
        },

        /**
         * Generate options markup
         *
         * @param  {object} items - Object containing all available options
         * @return {string}         HTML for the options box
         */
        getItemsMarkup: function(items) {
            var _this = this;
            var markup = '<ul>';

            if ($.isFunction(_this.options.listBuilder) && _this.options.listBuilder) {
                items = _this.options.listBuilder(items);
            }

            $.each(items, function(i, elm) {
                if (elm.label !== undefined) {

                    markup += _this.utils.format('<ul class="{1}"><li class="{2}">{3}</li>',
                        _this.utils.arrayToClassname([
                            _this.classes.group,
                            elm.groupDisabled ? 'disabled' : '',
                            elm.element.prop('class')
                        ]),
                        _this.classes.grouplabel,
                        elm.element.prop('label')
                    );

                    $.each(elm.items, function(i, elm) {
                        markup += _this.getItemMarkup(elm.index, elm);
                    });

                    markup += '</ul>';

                } else {

                    markup += _this.getItemMarkup(elm.index, elm);

                }
            });

            return markup + '</ul>';
        },

        /**
         * Generate every option markup
         *
         * @param  {number} index    - Index of current item
         * @param  {object} itemData - Current item
         * @return {string}            HTML for the option
         */
        getItemMarkup: function(index, itemData) {
            var _this = this;
            var itemBuilder = _this.options.optionsItemBuilder;
            // limit access to item data to provide a simple interface
            // to most relevant options.
            var filteredItemData = {
                value: itemData.value,
                text: itemData.text,
                slug: itemData.slug,
                index: itemData.index
            };

            return _this.utils.format('<li data-index="{1}" class="{2}">{3}</li>',
                index,
                _this.utils.arrayToClassname([
                    itemData.className,
                    index === _this.items.length - 1 ? 'last' : '',
                    itemData.disabled ? 'disabled' : '',
                    itemData.selected ? 'selected' : ''
                ]),
                $.isFunction(itemBuilder) ?
                _this.utils.format(itemBuilder(itemData, this.$element, index), itemData) :
                _this.utils.format(itemBuilder, filteredItemData)
            );
        },

        /** Remove events on the elements */
        unbindEvents: function() {
            var _this = this;

            _this.elements.wrapper
                .add(_this.$element)
                .add(_this.elements.outerWrapper)
                .add(_this.elements.input)
                .off(eventNamespaceSuffix);
        },

        /** Bind events on the elements */
        bindEvents: function() {
            var _this = this;

            _this.elements.outerWrapper.on('mouseenter' + eventNamespaceSuffix + ' mouseleave' + eventNamespaceSuffix, function(e) {
                $(this).toggleClass(_this.classes.hover, e.type === 'mouseenter');

                // Delay close effect when openOnHover is true
                if (_this.options.openOnHover) {
                    clearTimeout(_this.closeTimer);

                    if (e.type === 'mouseleave') {
                        _this.closeTimer = setTimeout($.proxy(_this.close, _this), _this.options.hoverIntentTimeout);
                    } else {
                        _this.open();
                    }
                }
            });

            // Toggle open/close
            _this.elements.wrapper.on('click' + eventNamespaceSuffix, function(e) {
                _this.state.opened ? _this.close() : _this.open(e);
            });

            // Translate original element focus event to dummy input.
            // Disabled on mobile devices because the default option list isn't
            // shown due the fact that hidden input gets focused
            if (!(_this.options.nativeOnMobile && _this.utils.isMobile())) {
                _this.$element.on('focus' + eventNamespaceSuffix, function() {
                    _this.elements.input.focus();
                });

                _this.elements.input
                    .prop({ tabindex: _this.originalTabindex, disabled: false })
                    .on('keydown' + eventNamespaceSuffix, $.proxy(_this.handleKeys, _this))
                    .on('focusin' + eventNamespaceSuffix, function(e) {
                        _this.elements.outerWrapper.addClass(_this.classes.focus);

                        // Prevent the flicker when focusing out and back again in the browser window
                        _this.elements.input.one('blur', function() {
                            _this.elements.input.blur();
                        });

                        if (_this.options.openOnFocus && !_this.state.opened) {
                            _this.open(e);
                        }
                    })
                    .on('focusout' + eventNamespaceSuffix, function() {
                        _this.elements.outerWrapper.removeClass(_this.classes.focus);
                    })
                    .on('input propertychange', function() {
                        var val = _this.elements.input.val();
                        var searchRegExp = new RegExp('^' + _this.utils.escapeRegExp(val), 'i');

                        // Clear search
                        clearTimeout(_this.resetStr);
                        _this.resetStr = setTimeout(function() {
                            _this.elements.input.val('');
                        }, _this.options.keySearchTimeout);

                        if (val.length) {
                            // Search in select options
                            $.each(_this.items, function(i, elm) {
                                if (elm.disabled) {
                                    return;
                                }
                                if (searchRegExp.test(elm.text) || searchRegExp.test(elm.slug)) {
                                    _this.highlight(i);
                                    return false;
                                }
                                if (!elm.alt) {
                                    return;
                                }
                                var altItems = elm.alt.split('|');
                                for (var ai = 0; ai < altItems.length; ai++) {
                                    if (!altItems[ai]) {
                                        break;
                                    }
                                    if (searchRegExp.test(altItems[ai].trim())) {
                                        _this.highlight(i);
                                        return false;
                                    }
                                }
                            });
                        }
                    });
            }

            _this.$li.on({
                // Prevent <input> blur on Chrome
                mousedown: function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                },
                click: function() {
                    _this.select($(this).data('index'));

                    // Chrome doesn't close options box if select is wrapped with a label
                    // We need to 'return false' to avoid that
                    return false;
                }
            });
        },

        /**
         * Behavior when keyboard keys is pressed
         *
         * @param {object} e - Event object
         */
        handleKeys: function(e) {
            var _this = this;
            var key = e.which;
            var keys = _this.options.keys;

            var isPrevKey = $.inArray(key, keys.previous) > -1;
            var isNextKey = $.inArray(key, keys.next) > -1;
            var isSelectKey = $.inArray(key, keys.select) > -1;
            var isOpenKey = $.inArray(key, keys.open) > -1;
            var idx = _this.state.highlightedIdx;
            var isFirstOrLastItem = (isPrevKey && idx === 0) || (isNextKey && (idx + 1) === _this.items.length);
            var goToItem = 0;

            // Enter / Space
            if (key === 13 || key === 32) {
                e.preventDefault();
            }

            // If it's a directional key
            if (isPrevKey || isNextKey) {
                if (!_this.options.allowWrap && isFirstOrLastItem) {
                    return;
                }

                if (isPrevKey) {
                    goToItem = _this.utils.previousEnabledItem(_this.lookupItems, idx);
                }

                if (isNextKey) {
                    goToItem = _this.utils.nextEnabledItem(_this.lookupItems, idx);
                }

                _this.highlight(goToItem);
            }

            // Tab / Enter / ESC
            if (isSelectKey && _this.state.opened) {
                _this.select(idx);

                if (!_this.state.multiple || !_this.options.multiple.keepMenuOpen) {
                    _this.close();
                }

                return;
            }

            // Space / Enter / Left / Up / Right / Down
            if (isOpenKey && !_this.state.opened) {
                _this.open();
            }
        },

        /** Update the items object */
        refresh: function() {
            var _this = this;

            _this.populate();
            _this.activate();
            _this.utils.triggerCallback('Refresh', _this);
        },

        /** Set options box width/height */
        setOptionsDimensions: function() {
            var _this = this;

            // Calculate options box height
            // Set a temporary class on the hidden parent of the element
            var hiddenChildren = _this.elements.items.closest(':visible').children(':hidden').addClass(_this.classes.tempshow);
            var maxHeight = _this.options.maxHeight;
            var itemsWidth = _this.elements.items.outerWidth();
            var wrapperWidth = _this.elements.wrapper.outerWidth() - (itemsWidth - _this.elements.items.width());

            // Set the dimensions, minimum is wrapper width, expand for long items if option is true
            if (!_this.options.expandToItemText || wrapperWidth > itemsWidth) {
                _this.finalWidth = wrapperWidth;
            } else {
                // Make sure the scrollbar width is included
                _this.elements.items.css('overflow', 'scroll');

                // Set a really long width for _this.elements.outerWrapper
                _this.elements.outerWrapper.width(9e4);
                _this.finalWidth = _this.elements.items.width();
                // Set scroll bar to auto
                _this.elements.items.css('overflow', '');
                _this.elements.outerWrapper.width('');
            }

            _this.elements.items.width(_this.finalWidth).height() > maxHeight && _this.elements.items.height(maxHeight);

            // Remove the temporary class
            hiddenChildren.removeClass(_this.classes.tempshow);
        },

        /** Detect if the options box is inside the window */
        isInViewport: function() {
            var _this = this;

            if (_this.options.forceRenderAbove === true) {
                _this.elements.outerWrapper.addClass(_this.classes.above);
            } else if (_this.options.forceRenderBelow === true) {
                _this.elements.outerWrapper.addClass(_this.classes.below);
            } else {
                var scrollTop = $win.scrollTop();
                var winHeight = $win.height();
                var uiPosX = _this.elements.outerWrapper.offset().top;
                var uiHeight = _this.elements.outerWrapper.outerHeight();

                var fitsDown = (uiPosX + uiHeight + _this.itemsHeight) <= (scrollTop + winHeight);
                var fitsAbove = (uiPosX - _this.itemsHeight) > scrollTop;

                // If it does not fit below, only render it
                // above it fit's there.
                // It's acceptable that the user needs to
                // scroll the viewport to see the cut off UI
                var renderAbove = !fitsDown && fitsAbove;
                var renderBelow = !renderAbove;

                _this.elements.outerWrapper.toggleClass(_this.classes.above, renderAbove);
                _this.elements.outerWrapper.toggleClass(_this.classes.below, renderBelow);
            }
        },

        /**
         * Detect if currently selected option is visible and scroll the options box to show it
         *
         * @param {Number|Array} index - Index of the selected items
         */
        detectItemVisibility: function(index) {
            var _this = this;
            var $filteredLi = _this.$li.filter('[data-index]');

            if (_this.state.multiple) {
                // If index is an array, we can assume a multiple select and we
                // want to scroll to the uppermost selected item!
                // Math.min.apply(Math, index) returns the lowest entry in an Array.
                index = ($.isArray(index) && index.length === 0) ? 0 : index;
                index = $.isArray(index) ? Math.min.apply(Math, index) : index;
            }

            var liHeight = $filteredLi.eq(index).outerHeight();
            var liTop = $filteredLi[index].offsetTop;
            var itemsScrollTop = _this.elements.itemsScroll.scrollTop();
            var scrollT = liTop + liHeight * 2;

            _this.elements.itemsScroll.scrollTop(
                scrollT > itemsScrollTop + _this.itemsHeight ? scrollT - _this.itemsHeight :
                liTop - liHeight < itemsScrollTop ? liTop - liHeight :
                itemsScrollTop
            );
        },

        /**
         * Open the select options box
         *
         * @param {Event} e - Event
         */
        open: function(e) {
            var _this = this;

            if (_this.options.nativeOnMobile && _this.utils.isMobile()) {
                return false;
            }

            _this.utils.triggerCallback('BeforeOpen', _this);

            if (e) {
                e.preventDefault();
                if (_this.options.stopPropagation) {
                    e.stopPropagation();
                }
            }

            if (_this.state.enabled) {
                _this.setOptionsDimensions();

                // Find any other opened instances of select and close it
                $('.' + _this.classes.hideselect, '.' + _this.classes.open).children()[pluginName]('close');

                _this.state.opened = true;
                _this.itemsHeight = _this.elements.items.outerHeight();
                _this.itemsInnerHeight = _this.elements.items.height();

                // Toggle options box visibility
                _this.elements.outerWrapper.addClass(_this.classes.open);

                // Give dummy input focus
                _this.elements.input.val('');
                if (e && e.type !== 'focusin') {
                    _this.elements.input.focus();
                }

                // Delayed binds events on Document to make label clicks work
                setTimeout(function() {
                    $doc
                        .on('click' + eventNamespaceSuffix, $.proxy(_this.close, _this))
                        .on('scroll' + eventNamespaceSuffix, $.proxy(_this.isInViewport, _this));
                }, 1);

                _this.isInViewport();

                // Prevent window scroll when using mouse wheel inside items box
                if (_this.options.preventWindowScroll) {
                    /* istanbul ignore next */
                    $doc.on('mousewheel' + eventNamespaceSuffix + ' DOMMouseScroll' + eventNamespaceSuffix, '.' + _this.classes.scroll, function(e) {
                        var orgEvent = e.originalEvent;
                        var scrollTop = $(this).scrollTop();
                        var deltaY = 0;

                        if ('detail' in orgEvent) { deltaY = orgEvent.detail * -1; }
                        if ('wheelDelta' in orgEvent) { deltaY = orgEvent.wheelDelta; }
                        if ('wheelDeltaY' in orgEvent) { deltaY = orgEvent.wheelDeltaY; }
                        if ('deltaY' in orgEvent) { deltaY = orgEvent.deltaY * -1; }

                        if (scrollTop === (this.scrollHeight - _this.itemsInnerHeight) && deltaY < 0 || scrollTop === 0 && deltaY > 0) {
                            e.preventDefault();
                        }
                    });
                }

                _this.detectItemVisibility(_this.state.selectedIdx);

                _this.highlight(_this.state.multiple ? -1 : _this.state.selectedIdx);

                _this.utils.triggerCallback('Open', _this);
            }
        },

        /** Close the select options box */
        close: function() {
            var _this = this;

            _this.utils.triggerCallback('BeforeClose', _this);

            // Remove custom events on document
            $doc.off(eventNamespaceSuffix);

            // Remove visible class to hide options box
            _this.elements.outerWrapper.removeClass(_this.classes.open);

            _this.state.opened = false;

            _this.utils.triggerCallback('Close', _this);
        },

        /** Select current option and change the label */
        change: function() {
            var _this = this;

            _this.utils.triggerCallback('BeforeChange', _this);

            if (_this.state.multiple) {
                // Reset old selected
                $.each(_this.lookupItems, function(idx) {
                    _this.lookupItems[idx].selected = false;
                    _this.$element.find('option').prop('selected', false);
                });

                // Set new selected
                $.each(_this.state.selectedIdx, function(idx, value) {
                    _this.lookupItems[value].selected = true;
                    _this.$element.find('option').eq(value).prop('selected', true);
                });

                _this.state.currValue = _this.state.selectedIdx;

                _this.setLabel();

                _this.utils.triggerCallback('Change', _this);
            } else if (_this.state.currValue !== _this.state.selectedIdx) {
                // Apply changed value to original select
                _this.$element
                    .prop('selectedIndex', _this.state.currValue = _this.state.selectedIdx)
                    .data('value', _this.lookupItems[_this.state.selectedIdx].text);

                // Change label text
                _this.setLabel();

                _this.utils.triggerCallback('Change', _this);
            }
        },

        /**
         * Highlight option
         * @param {number} index - Index of the options that will be highlighted
         */
        highlight: function(index) {
            var _this = this;
            var $filteredLi = _this.$li.filter('[data-index]').removeClass('highlighted');

            _this.utils.triggerCallback('BeforeHighlight', _this);

            // Parameter index is required and should not be a disabled item
            if (index === undefined || index === -1 || _this.lookupItems[index].disabled) {
                return;
            }

            $filteredLi
                .eq(_this.state.highlightedIdx = index)
                .addClass('highlighted');

            _this.detectItemVisibility(index);

            _this.utils.triggerCallback('Highlight', _this);
        },

        /**
         * Select option
         *
         * @param {number} index - Index of the option that will be selected
         */
        select: function(index) {
            var _this = this;
            var $filteredLi = _this.$li.filter('[data-index]');

            _this.utils.triggerCallback('BeforeSelect', _this, index);

            // Parameter index is required and should not be a disabled item
            if (index === undefined || index === -1 || _this.lookupItems[index].disabled) {
                return;
            }

            if (_this.state.multiple) {
                // Make sure selectedIdx is an array
                _this.state.selectedIdx = $.isArray(_this.state.selectedIdx) ? _this.state.selectedIdx : [_this.state.selectedIdx];

                var hasSelectedIndex = $.inArray(index, _this.state.selectedIdx);
                if (hasSelectedIndex !== -1) {
                    _this.state.selectedIdx.splice(hasSelectedIndex, 1);
                } else {
                    _this.state.selectedIdx.push(index);
                }

                $filteredLi
                    .removeClass('selected')
                    .filter(function(index) {
                        return $.inArray(index, _this.state.selectedIdx) !== -1;
                    })
                    .addClass('selected');
            } else {
                $filteredLi
                    .removeClass('selected')
                    .eq(_this.state.selectedIdx = index)
                    .addClass('selected');
            }

            if (!_this.state.multiple || !_this.options.multiple.keepMenuOpen) {
                _this.close();
            }

            _this.change();

            _this.utils.triggerCallback('Select', _this, index);
        },

        /**
         * Unbind and remove
         *
         * @param {boolean} preserveData - Check if the data on the element should be removed too
         */
        destroy: function(preserveData) {
            var _this = this;

            if (_this.state && _this.state.enabled) {
                _this.elements.items.add(_this.elements.wrapper).add(_this.elements.input).remove();

                if (!preserveData) {
                    _this.$element.removeData(pluginName).removeData('value');
                }

                _this.$element.prop('tabindex', _this.originalTabindex).off(eventNamespaceSuffix).off(_this.eventTriggers).unwrap().unwrap();

                _this.state.enabled = false;
            }
        }
    };

    // A really lightweight plugin wrapper around the constructor,
    // preventing against multiple instantiations
    $.fn[pluginName] = function(args) {
        return this.each(function() {
            var data = $.data(this, pluginName);

            if (data && !data.disableOnMobile) {
                (typeof args === 'string' && data[args]) ? data[args](): data.init(args);
            } else {
                $.data(this, pluginName, new Selectric(this, args));
            }
        });
    };

    /**
     * Default plugin options
     *
     * @type {object}
     */
    $.fn[pluginName].defaults = {
        onChange: function(elm) { $(elm).change(); },
        maxHeight: 300,
        keySearchTimeout: 500,
        arrowButtonMarkup: '<b class="button">&#x25be;</b>',
        disableOnMobile: false,
        nativeOnMobile: true,
        openOnFocus: true,
        openOnHover: false,
        hoverIntentTimeout: 500,
        expandToItemText: false,
        responsive: false,
        preventWindowScroll: true,
        inheritOriginalWidth: false,
        allowWrap: true,
        forceRenderAbove: false,
        forceRenderBelow: false,
        stopPropagation: true,
        optionsItemBuilder: '{text}', // function(itemData, element, index)
        labelBuilder: '{text}', // function(currItem)
        listBuilder: false, // function(items)
        keys: {
            previous: [37, 38], // Left / Up
            next: [39, 40], // Right / Down
            select: [9, 13, 27], // Tab / Enter / Escape
            open: [13, 32, 37, 38, 39, 40], // Enter / Space / Left / Up / Right / Down
            close: [9, 27] // Tab / Escape
        },
        customClass: {
            prefix: pluginName,
            camelCase: false
        },
        multiple: {
            separator: ', ',
            keepMenuOpen: true,
            maxLabelEntries: false
        }
    };
}));